Skip to content

fix(storage): surface degraded session database state - #86762

Closed
konsisumer wants to merge 1 commit into
NousResearch:mainfrom
konsisumer:fix/storage-degraded-state
Closed

konsisumer wants to merge 1 commit into
NousResearch:mainfrom
konsisumer:fix/storage-degraded-state

Conversation

@konsisumer

Copy link
Copy Markdown

What does this PR do?

Adds the phase-one failure semantics for a corrupt state.db: a confirmed unrecoverable malformed write latches the affected profile as degraded, pauses subsequent persistence, exposes the state to API clients, and shows a non-dismissable Desktop recovery notice. A successful in-place derived-FTS rebuild remains healthy; only a rebuild that cannot recover the write enters the latch.

The existing hermes sessions repair command remains the guided recovery path. Full cross-process quiescing and offline recovery orchestration are a separate phase, so this PR uses a non-closing reference.

Related Issue

Refs #72046

Addressing maintainer feedback

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✅ Tests (adding or improving test coverage)

Changes Made

  • hermes_state.py: adds a process-wide per-database degraded-state latch and short-circuits later writes after unrecoverable malformed corruption.
  • hermes_cli/web_server.py: latches failed session-store heals and adds the selected profile's storage state to /api/status.
  • hermes_cli/web_routers/sessions.py: includes storage in the /api/sessions response envelope.
  • apps/desktop/src/store/storage-status.ts: owns the renderer's storage-status atom.
  • apps/desktop/src/components/storage-degraded-banner.tsx: renders persistent repair guidance while writes are paused.
  • apps/desktop/src/app/contrib/wiring.tsx: mounts the Desktop banner globally.
  • apps/desktop/src/app/shell/hooks/use-status-snapshot.ts: synchronizes the REST status field into the renderer atom.
  • apps/desktop/src/types/hermes.ts: declares the new REST fields.
  • tests/test_state_db_malformed_repair.py: proves an unrepaired malformed write latches storage and blocks later appends.
  • tests/hermes_cli/test_web_server.py: proves session and status APIs surface the same degraded state.
  • apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts: proves Desktop publishes the status field to the persistent-warning state.

How to Test

  1. Run PATH="$VIRTUAL_ENV/bin:$PATH" /opt/homebrew/bin/timeout -k 30 480 sh -c 'pytest tests/ -q -x --timeout=60 "$@"' sh.
  2. Run /opt/homebrew/bin/timeout -k 30 480 "$VIRTUAL_ENV/bin/python" -m pytest tests/test_state_db_malformed_repair.py::test_unrepaired_malformed_write_latches_storage_and_pauses_later_writes tests/hermes_cli/test_web_server.py::TestWebServerEndpoints::test_storage_degraded_is_exposed_to_session_and_status_clients -q -x --timeout=60.
  3. With a failing malformed-write fixture, verify the first failed write raises StorageDegradedError, later writes are refused without retrying SQLite, /api/status and /api/sessions return storage: "degraded", and Desktop displays the repair guidance.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS (darwin-arm64)

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

For New Skills

N/A.

Screenshots / Logs

Focused backend coverage passed: 2 passed in 8.36s. The local Desktop Node dependencies are absent (vitest: command not found), so its new hook coverage is included for CI. The scoped Python lint passed; the Windows-footgun scanner found only four pre-existing encoding warnings outside this diff in tests/hermes_cli/test_web_server.py.

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/cli CLI entry point, hermes_cli/, setup wizard comp/desktop Electron desktop app (apps/desktop/*) sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state area/sessions Session lifecycle, resume, persistence, history labels Aug 15, 2026

@trevorgordon981 trevorgordon981 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sound, well-scoped fix. The process-local latch design is the right call — _storage_status_by_path with intentional reset-on-restart so a completed offline repair is adopted without a hidden persistent marker. The StorageDegradedError on write to a latched-store is a clean way to stop a write retry storm. Tests including test_unrepaired_malformed_write_latches_storage_and_pauses_later_writes are good. A couple of notes.

1. The "process-wide" guarantee is per-process, not cross-process

mark_storage_degraded latches into a module-global dict in the current process. Long-running backends may run as more than one process (a gateway writing state.db plus a second gateway, or a desktop poller). If corruption is first observed in process A, only A latches; process B, on its next write, redisovers via its own is_malformed_db_error path and latches independently. The docstring's framing — "Long-running backends create short-lived SessionDBs ... while the gateway keeps another instance" — is all one process, so within-process it's exactly right. Just be aware the guarantee doesn't extend across processes; if two writers exist, the write-retry-storm pause only kicks in after each process individually observes structural failure. Probably acceptable, but worth stating explicitly given "process-wide" reads as stronger than it is.

2. Latch only triggers on write/heal, not on a read-only surface

The latch fires through the write and schema-heal paths (mark_storage_degraded on is_malformed_db_error). A purely read-only SELECT against a malformed DB — the sidebar poll, say — may not hit that path, so the first sighting of corruption by a read-only process won't latch. The desktop banner relies on get_storage_status, which only reports "degraded" if some process latched it. If the first (and only) observer is read-only, the banner stays "ok" until a write occurs. Worth confirming the malformed-repair path also marks on read-side detection.

3. Minor

  • get_storage_status returns "ok" for an absent store too (key missing → "ok"). An uninitialized DB is not corrupted, so that's fine, but a caller can't distinguish "genuinely ok" from "never seen." Acceptable given the desktop only needs the degraded red flag.

Tests

Good coverage of the core: latch-on-malformed-write, pause-later-writes, plus the web-server/health-probe wiring in test_web_server.py and the TS hook/store tests including the caller-side storage: 'degraded' → $storageStatus propagation. The gap is cross-process (finding #1) — nothing exercises two independent SessionDB instances/processes seeing corruption.

@konsisumer

Copy link
Copy Markdown
Author

Rebased onto current origin/main and repaired the two lock-holder tests in tests/test_state_db_malformed_repair.py for main's live-system guard. tests/state/test_fts_runtime_rebuild.py and tests/test_state_db_malformed_repair.py pass (28 passed). The full Python suite cannot collect locally because FastAPI is absent and the sandbox blocks the lazy installer's UV cache; CI covers the web-server and Desktop tests. The 11-file PR diff is the pre-existing original scope; this repair adds only two test-marker lines.

@konsisumer
konsisumer force-pushed the fix/storage-degraded-state branch from 9f61f36 to f364df2 Compare August 15, 2026 11:55
@Enough1122

Copy link
Copy Markdown

AI code review — automated review for reference, author can ignore or act on any point.

fix(storage): surface degraded session database state

  • status["storage"] and components["storage"]["status"] can disagree: the latch (get_storage_status) is only set on write/open-repair failures (hermes_state.py:3958-3966, _open_probed), never on read-side malformed errors. A corrupted DB that is only ever READ (session-list polling is a read path) will show components.storage.status: degraded from the probe while status.storage: ok — and the desktop banner keys off status.storage, so read-only corruption never surfaces the banner. Consider marking degraded on read-side malformed errors too.
  • StorageDegradedError is raised on every subsequent write through _execute_write. Verify the repair tooling (hermes sessions repair) and any auto-recovery paths don't go through SessionDB._execute_write, or the latch would block the very repair it tells the user to run. The process-local reset on restart mitigates offline repair, but an in-process repair would deadlock against the latch.
  • The new E2E test test_storage_degraded_is_exposed_to_session_and_status_clients (tests/hermes_cli/test_web_server.py:400-419) latches mark_storage_degraded on the shared state.db path and never resets it — _storage_status_by_path is module-global and persists for the process. If any later test in the same process opens the same state.db, it will inherit degraded and writes will start raising StorageDegradedError. Clear the latch (or monkeypatch the dict) at test teardown to avoid cross-test contamination.
  • Minor: get_sessions now resolves get_storage_status(db.db_path) per request — negligible overhead, fine; just confirming the resolved-path keying (resolve(strict=False)) matches the get_hermes_home()/state.db spelling used in the health probe.

@trevorgordon981 trevorgordon981 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking finding from re-reviewing the current head (f364df22): the PR's own FTS-rebuild test suite is red, and CI slice 4/12 fails because of it. This is a genuine internal regression, not a pre-existing flake.

What fails: tests/state/test_fts_runtime_rebuild.py — 6 of 11 tests fail (reproduced locally on this head):

  • test_second_corruption_fails_open_and_rebuilds_on_reopen
  • test_failed_in_place_rebuild_fails_open
  • test_stale_search_preserves_not_semantics
  • test_existing_peer_observes_fail_open_marker
  • test_failed_startup_rebuild_keeps_fts_detached
  • test_legacy_inline_fts_fails_open_and_recovers

Root cause: hermes_state.py:4069-4079 now raises StorageDegradedError as soon as a write hits an FTS-corruption error class after an in-place rebuild fails:

if is_malformed_db_error(exc):
    mark_storage_degraded(self.db_path, exc)
    raise StorageDegradedError(...)  # hard-fails persistence

But the whole point of the existing FTS fail-open path (. _try_runtime_fts_rebuild, then . _enter_fts_fail_open) is that when the one-shot rebuild fails, the DB should detach the derived FTS indexes and continue in a degraded-but-working mode, preserving canonical message rows (the warning log at 4218/4226 documents exactly this intent). The new degrade-latch now turns that recovery into a hard StorageDegradedError that blocks all persistence — which is precisely what the fail-open tests assert must not happen.

Notably, the author's own summary says "test_fts_runtime_rebuild.py ... pass (28 passed)" — that's not the case here; 6/11 fail, and CI slice 4/12 is red on this exact file.

Suggested direction: the degraded-surface feature and the FTS fail-open recovery need to coexist. The latch should mark the storage degraded (so the banner/API surface it) without raising past the fail-open recovery — i.e. let _enter_fts_fail_open / the no-FTS degraded mode proceed, and have mark_storage_degraded reflect the partially-degraded state rather than hard-failing the write. That also lines up with my earlier finding #2 (the banner should report degraded on read-side observation, not only when a write hard-fails).

Happy to dig into the exact call-flow if useful.

@konsisumer

Copy link
Copy Markdown
Author

Thanks for the review. Addressed in this push:

  • FTS fail-open regression: restored the established fail-open order. A failed FTS rebuild now detaches the derived indexes, reports storage as degraded, and retries the canonical write; writes pause only if that detach/recovery cannot proceed.
  • Read-side corruption: malformed read errors now surface the same degraded storage state without blocking the FTS read self-heal path.
  • Repair path: hermes sessions repair calls repair_state_db_schema directly, not SessionDB._execute_write, so the write latch does not block it.
  • Test isolation and UI copy: the web API test now monkeypatches the process-global latch, and the persistent notice no longer incorrectly says persistence is always paused.

Verified: pytest tests/state/test_fts_runtime_rebuild.py tests/test_state_db_malformed_repair.py -q -x --timeout=60 (29 passed). The web-server suite cannot collect locally because FastAPI is absent and lazy installs are disabled.

@konsisumer
konsisumer force-pushed the fix/storage-degraded-state branch from f364df2 to 250e8cc Compare August 21, 2026 07:15
@konsisumer

Copy link
Copy Markdown
Author

Rebased onto current main and resolved the two in-scope conflicts. Generic SQLite corruption now latches degraded storage without reviving the writable schema-heal path.

Verified: 4 focused storage/session tests, Ruff, Windows-footguns scan, and whitespace checks. The bounded full suite stops during collection on the unrelated optional Teams dependency assertion in tests/gateway/test_teams.py.

@konsisumer
konsisumer force-pushed the fix/storage-degraded-state branch from 250e8cc to 7254cfe Compare August 22, 2026 23:49
@konsisumer

Copy link
Copy Markdown
Author

Rebased onto current origin/main, resolving the in-scope status-endpoint conflict while preserving the original 11-file storage-degraded-state scope. Also corrected five existing UTF-8 fixture diagnostics in tests/hermes_cli/test_web_server.py, required by the Windows-footguns check.

Verified: malformed-repair tests (19 passed), Ruff, Windows-footguns, Python compilation, and whitespace checks. The mandated full Python suite stops during collection because FastAPI dashboard extras are absent and the sandbox cannot initialize the uv cache (exit 3); no test regression in this PR's changed storage test file was observed.

test verification unavailable: the recorded local test command exited 3

@konsisumer
konsisumer force-pushed the fix/storage-degraded-state branch from 7254cfe to c909b41 Compare August 27, 2026 19:24

@trevorgordon981 trevorgordon981 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the current head (c909b410). The blocking finding from my last pass is resolved, and the two advisory ones are addressed as well.

1. FTS fail-open regression (was blocking) — fixed. The write path at hermes_state.py:5348-5365 now runs the recovery in the right order: _try_runtime_fts_rebuild first, then _enter_fts_fail_open with mark_storage_degraded(..., pause_writes=False) and a continue so the canonical write is retried. StorageDegradedError is only raised when fail-open could not detach the derived indexes — which is the correct semantics: degraded-but-persisting beats blocking persistence on a derived-index failure. The comment at 5351-5353 documents the intent, good.

2. Read-side observation — fixed. Both read paths (pooled connection at ~4955 and the single-conn fallback at ~4985) now mark_storage_degraded(..., pause_writes=False) on is_malformed_db_error, so a read-only observer (sidebar poll, MATCH read) surfaces the degraded state instead of leaving the banner "ok" until some write happens. That closes the gap from my original finding #2.

3. Latch monotonicity. mark_storage_degraded keeps writes_were_paused or pause_writes, so a later fail-open observation can't un-pause a write-paused latch. Correct direction.

Verification. Local run of tests/state/test_fts_runtime_rebuild.py + tests/test_state_db_malformed_repair.py on this head is not meaningful on my machine: SQLite 3.51.0 refuses the shadow-table and writable_schema writes the corruption helpers rely on (table messages_fts_data may not be modified), and the same suites fail identically on current main (36/43) — pre-existing environment limitation, not a PR regression. The authoritative signal is CI on this head: all required checks pass, including the Python tests slice that was red on the Aug 15 head and the macOS-only suite. The repair-path change (hermes sessions repair calling repair_state_db_schema directly, bypassing the write latch) is the right call — the repair tool must not be blocked by the latch it exists to clear.

Blocking finding withdrawn. From my side this is merge-ready.

@konsisumer
konsisumer force-pushed the fix/storage-degraded-state branch from c909b41 to 78fea33 Compare August 28, 2026 06:53
@konsisumer

Copy link
Copy Markdown
Author

Rebased onto current origin/main, resolving the in-scope Desktop wiring import conflict while preserving the PR's pre-existing 11-file, 282-line scope.

Verified: whitespace, Ruff, Windows-footguns, and tests/test_state_db_malformed_repair.py (25 passed). The API and Desktop targeted checks cannot run locally because FastAPI/Vitest are unavailable and lazy/network installs are disabled; CI was green before this rebase.

test verification unavailable: no local test exit code was recorded

@konsisumer

Copy link
Copy Markdown
Author

Rebased onto current origin/main and resolved the two in-scope storage conflicts, preserving main's structural-corruption quarantine while retaining this PR's process-wide degraded status and pause latch.

The final 11-file, 297-line diff is the PR's pre-existing scope; this repair only reconciles the conflict semantics and removes a stale FTS-rebuild test stub. Focused storage/quarantine coverage passed (5 passed); Ruff, syntax, whitespace, and the Windows-footguns scan pass.

The mandated full suite stopped during collection because the optional FastAPI dashboard dependency is absent and the sandbox blocks its UV cache (exit 3).

@andrexibiza andrexibiza left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head 97b04ab9f4d2ae9f8ea013f031141c13e339cff7 against exact base 63279301bcbdc185c1b07b98a9312eb0c862f26d, including the full 11-file diff, the fresh rebase conflict resolution, existing review history, #72046, the merged prevention/repair work in #71724 and #84882, current-main structural quarantine from #101224 (salvage of #101095, with leomcamilo's authorship preserved), the gateway/agent durability fallbacks that quarantine owns, and the adjacent open quarantine hardening in #102092 / #101303 / #101276. This head is a single commit, so the every-commit gate is the head gate; CI, Docker, and Nix all complete successfully on this exact object.

The earlier FTS regression is genuinely resolved on this rebase: _enter_fts_fail_open() still gets first refusal on derived-index corruption, that path marks the profile visible-degraded with pause_writes=False, and canonical persistence remains available. Bare structural corruption then composes with main's stronger per-handle _halt_db_corrupt() quarantine. The read-side observation path likewise surfaces malformed reads without automatically stopping canonical writes. Those are good separations.

I still see two merge blockers at the current head, both created by the new composition with main rather than by the older review findings.

1. The process-wide pause creates a second permanent-refusal error class, but main's transcript-durability owners do not recognize it.

tests/test_state_db_malformed_repair.py::test_unrepaired_malformed_write_latches_storage_and_pauses_later_writes now proves the important cross-handle case explicitly: handle A observes structural corruption and raises StateDbCorruptError; a fresh handle B for the same path reaches _storage_writes_paused() and raises StorageDegradedError before touching SQLite.

That is exactly where this PR currently falls out of main's quarantine contract:

  • gateway/session.py::_append_to_transcript_serialized() diverts to JSONL + pending_messages/ and suppresses the FTS retry only for StateDbReplacedError / StateDbCorruptError. Other failures fall through to the bounded dirty-transcript path and log will retry.
  • run_agent.py::_flush_messages_to_session_db() likewise sends the batch to divert_session_transcript_jsonl() only for StateDbReplacedError / StateDbCorruptError.
  • classify_persistence_error() recognizes StateDbCorruptError by type and raw malformed/NOTADB text as corrupt, but StorageDegradedError("session database is degraded after a corruption error; persistence is paused...") matches neither and currently classifies as unknown.

So the first handle gets #101224's fail-closed and durable-fallback behavior, while every peer handle stopped by this PR's process-wide latch gets fail-closed SQLite writes but loses the matching fallback/classification semantics. That can put gateway messages back into the in-memory retry queue instead of immediately preserving them on disk, which is precisely the retry/durability half #101224 closed.

Please make the global paused-write result participate in the same terminal persistence contract. The implementation can be inheritance or a shared semantic/base class rather than special-casing one more name, but all three owners above need to agree that this result is corrupt/non-retryable and must divert. I would add two acceptance tests around the fresh peer after latch shape: gateway append => zero FTS rebuild/retry + JSONL/spool diversion; agent flush => JSONL diversion; and a classifier assertion that the peer refusal is corrupt. That tests the actual other side of the new latch rather than only proving that SQLite was not touched.

2. The Desktop recovery instruction now conflicts with the structural-corruption recovery authority in this exact base.

StorageDegradedBanner gives one unconditional instruction: stop profile writers, then run hermes sessions repair, and says historical chats are preserved. But current main deliberately distinguishes two different degraded shapes that this PR collapses into the same storage: "degraded" value:

  • derived FTS failure can degrade while canonical writes remain available;
  • bare structural SQLITE_CORRUPT / SQLITE_NOTADB can mean canonical B-tree/schema/freelist damage, pauses writes, and main's StateDbCorruptError / docs/state-db-recovery.md lead with hermes sessions recover --source ... --inspect-only (or restore a snapshot), preserving the damaged source and WAL bundle for recovery.

hermes sessions repair is still a valid guarded repair facility, and #84882 correctly serialized that surgery across processes, but it is not the same first-line contract main now emits for a quarantined structural image. Because the banner is the new durable user-facing safety surface, it should not send both states through one stronger claim/action than the backend can justify. Either expose enough mode/reason to render the correct guidance, or make the common copy conservative (no unconditional preservation guarantee; point structural quarantine to the non-destructive inspect/recover path). I would cover the rendered structural case in the Desktop test, not just atom publication.

Interlock/credit check: this is complementary to #71724's corruption-prevention work and #84882's repair serialization, not a duplicate. It also needs to preserve #101224/#101095's structural-quarantine ownership rather than becoming a parallel error protocol. #102092, #101303, and #101276 are adjacent hardening of that same quarantine across maintenance/read-open/housekeeping paths; they are complementary and should compose without changing this PR's status/UI ownership, but whichever lands first should be rechecked for the shared no-touch/no-retry invariant.

The rebase did a substantial amount of hard work correctly: the old FTS fail-open blocker is gone, the new latch is monotonic, the status plumbing is narrow, and the exact head is fully green. The remaining problem is the last ownership hop from “peer write is refused” to “the transcript is durably diverted and the user is told the recovery action main actually owns.” Close those two seams and this becomes a coherent extension of the current corruption architecture.

@konsisumer

Copy link
Copy Markdown
Author

Thanks for the detailed re-review. Addressed in this push:

The overall 11-file diff was already near the 300-line review threshold; this update is limited to four existing PR files (29 additions, 9 deletions) for the requested inheritance, recovery copy, and coverage. The mandated Python suite exited 3 during FastAPI collection because the sandbox lacks FastAPI and cannot initialize its uv cache; Desktop Vitest is not installed locally.

test verification unavailable: the recorded local test command exited 3

@konsisumer
konsisumer force-pushed the fix/storage-degraded-state branch from 97b04ab to da367e6 Compare September 4, 2026 07:34
@konsisumer

Copy link
Copy Markdown
Author

Fixed the two ESLint import-order diagnostics in the existing Desktop status-snapshot test. npm --workspace apps/desktop run check:lint and the status-snapshot UI test (8 passed) are green; the Windows-footguns and whitespace checks pass. The mandated Python suite exits 3 during FastAPI collection because the sandbox cannot initialize its uv cache. The overall 11-file, 319-line diff is the PR's pre-existing scope; this update changes one file (2 additions, 2 deletions).

@konsisumer
konsisumer force-pushed the fix/storage-degraded-state branch from da367e6 to 52606bd Compare September 4, 2026 19:24
@austinpickett

Copy link
Copy Markdown
Collaborator

Superseded by #120274. Thanks @konsisumer. The design is yours: tell the user the store is degraded, return 503 instead of 500, and show a Desktop notice instead of an empty sidebar. Both commits in #120274 carry you as co-author.

Why a new PR rather than this one:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/sessions Session lifecycle, resume, persistence, history comp/cli CLI entry point, hermes_cli/, setup wizard comp/desktop Electron desktop app (apps/desktop/*) P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants